<?php
// Copyright 2019 Hackware SpA <human@hackware.cl>
// This file is part of "Hackware Web Services Wallet" and licensed under
// the terms of the GNU Affero General Public License version 3, or (at your
// option) a later version. You should have received a copy of this license
// along with the software. If not, see <https://www.gnu.org/licenses/>.

namespace Hawese\Wallet;

use Hawese\Core\Mailer;
use Hawese\Core\User;
use Hawese\Core\Exceptions\ModelObjectNotFoundException;
use Illuminate\Support\Carbon;
use DateInterval;

// `const` constants cannot be defined on run-time, so...
define('TRANSACTION_TYPES', 'system,' . config('wallet.transaction_types'));

class Transaction extends TableModel
{
    const BALANCE_P = 8; // positions after . in a float, used with BCMath
    const AMOUNT_P = 8;

    private $current_balance;

    public static $table = 'transactions';
    public static $attributes = [
        'id' => ['nullable', 'integer', 'min:1'],
        'user_uid' => [
            'required', 'string', 'min:3', 'max:100' // exists:wallets,user_uid
        ],
        'currency_id' => [
            'required', 'integer', 'min:1', 'exists:currencies,id'
        ],
        'type' => [
            'required',
            'in:' . TRANSACTION_TYPES,
            'string',
            'max:100'
        ],
        'amount' => ['required', 'regex:/^-?\d{1,8}(?:\.\d{1,8})?$/'],
        'balance' => ['nullable', 'regex:/^-?\d{1,8}(?:\.\d{1,8})?$/'],
        'description' => ['nullable', 'string', 'max:255'],
        'detail' => ['nullable', 'max:65535'], // JSON
        'due_at' => ['nullable', 'date'],
        'created_at' => ['nullable', 'date'],
        'updated_at' => ['nullable', 'date']
    ];
    protected $appended_attributes = ['currency_code'];

    public static $foreign_keys = [
        'currency_id' => Currency::class,
        'user_uid' => Wallet::class
    ];

    public function getDetail()
    {
        return json_decode($this->data['detail']);
    }

    public function setDetail($value)
    {
        $this->data['detail'] = json_encode($value);
    }

    // FIXME: N+1 problem.
    public function getCurrencyCode()
    {
        return $this->foreignObjectGetter('currency')->code;
    }

    public function setDueAt($value) : void
    {
        $this->dateSetter('due_at', $value);
    }

    public function update($fields = []) : bool
    {
        return false;
    }

    public function delete() : bool
    {
        return false;
    }

    public function insert(): bool
    {
        // FIXME: Validated twice! Check hawese-core issue #5
        $this->validate();

        return app('db')->transaction(function () {
            $wallet = $this->findOrCreateWallet();
            $this->current_balance = $this->currentBalance();
            $this->balance = bcadd(
                $this->current_balance,
                $this->amount,
                self::BALANCE_P
            );
            $this->processDueTransactions();
            return parent::insert();
        });
    }

    private function findOrCreateWallet() : Wallet
    {
        try {
            $wallet = Wallet::find($this->user_uid);
        } catch (ModelObjectNotFoundException $e) {
            $wallet = new Wallet([
                'user_uid' => $this->user_uid,
                'currency_code' => $this->foreignObjectGetter('currency')->code,
            ]);
            $wallet->insert();
            $wallet->initialTransaction();
        }
        return $wallet;
    }

    private function currentBalance(): string
    {
        return app('db')->selectOne(
            'SELECT balance' .
            ' FROM ' . self::$table .
            ' WHERE user_uid = ?' .
            ' ORDER BY id desc',
            [$this->user_uid]
        )->balance ?? '0'; // In case wallet is new, so there's not balance
    }

    /**
     * Process due transactions.
     *
     * Updates due_at in all related transactions.
     * Notice only negative amount transactions can be due_at.
     */
    private function processDueTransactions(): void
    {
        // Balance is positive: ($this->balance + $this->amount) >= 0
        if (bccomp(
            $this->balance,
            0,
            self::BALANCE_P
        ) != -1) {
            $this->due_at = null;
            // ...and was negative: $this->current_balance < 0
            if (bccomp($this->current_balance, 0, self::BALANCE_P) == -1) {
                $this->clearAllTransactionsDueAt(); // ...then clear all due_at
            }
        // Balance is negative
        } else {
           // ...but transaction reduces debt: $this->amount >= 0
            if (bccomp($this->amount, 0, self::AMOUNT_P) != -1) {
                $this->clearUpToNextDue();
            // ...and increases debt
            } else {
                $this->due_at = Carbon::now()->add(
                    DateInterval::createFromDateString(
                        config('wallet.due_after')
                    )
                );
            }
        }
    }

    private function clearAllTransactionsDueAt()
    {
        $ids = array_map(
            function ($item) {
                return $item->id;
            },
            app('db')->select(
                'SELECT id' .
                ' FROM ' . self::$table .
                ' WHERE due_at IS NOT NULL' .
                ' AND user_uid = ?',
                [$this->user_uid]
            )
        );

        self::clearTransactionsDueAt($ids);
    }

    private static function clearTransactionsDueAt(array $ids): void
    {
        if (count($ids)) {
            app('db')->update(
                'UPDATE ' . self::$table .
                ' SET due_at = NULL, updated_at = ?' .
                ' WHERE id IN (' . self::commaStrRepeat('?', count($ids)) . ')',
                array_merge([Carbon::now()], $ids)
            );

            PartialPayment::staticDelete($ids);
        }
    }

    private function clearUpToNextDue(): void
    {
        $this_amount = $this->amount;
        $paid_due_transaction_ids = [];

        foreach ($this->dueTransactions() as $due_transaction) {
            $left_to_pay = $due_transaction->leftToPay();

            // $amount is less than amount owed in current transaction:
            // $this_amount < $left_to_pay
            if (bccomp($this_amount, $left_to_pay, self::AMOUNT_P) == -1
                and $this_amount != 0
            ) {
                PartialPayment::addOrCreate(
                    $due_transaction->id,
                    $this_amount
                );
                break;
            // $amount exceeds amount owed in current transaction
            } else {
                // ...so it's paid
                array_push($paid_due_transaction_ids, $due_transaction->id);
                $this_amount = bcsub(
                    $this_amount,
                    $left_to_pay,
                    self::AMOUNT_P
                );
            }
        }

        self::clearTransactionsDueAt($paid_due_transaction_ids);
    }

    private function dueTransactions()
    {
        return Transaction::processCollection(
            Transaction::select()
                ->where('user_uid', $this->user_uid)
                ->whereNotNull('due_at')
                ->orderBy('due_at', 'asc')
                ->get()
        )->get();
    }

    /**
     * Amount left to pay on a Transaction with due_at.
     *
     * Notice:
     * - This function does NOT say if Wallet has a debt, but this transaction.
     * - Uses $balance instead of $current_balance since balance is already
     *   inserted in database (never edit a Transaction amount/balance).
     */
    public function leftToPay(): string
    {
        // prev balance > 0: $this->balance > $this->amount
        if (bccomp($this->balance, $this->amount, self::BALANCE_P) == 1) {
            $amount = $this->balance;
        } else {
            $amount = $this->amount;
        }
        $amount = self::bcAbs($amount);

        if (isset($this->due_at)) {
            try {
                return bcsub(
                    $amount,
                    PartialPayment::find($this->id)->amount,
                    self::AMOUNT_P
                );
            } catch (ModelObjectNotFoundException $e) {
                return $amount;
            }
        }

        return '0'; // There's no debt
    }

    public function isOwner(User $user): bool
    {
        return $this->user_uid === $user->uid;
    }

    /**
     * @param $to string non-default email.
     * @param $type string notification or expiration
     * @return bool succesfully sent?
     */
    public function emailNotification(
        string $type='transaction',
        string $to = null
    ): bool {
        $subjects = [
            'transaction' => __('Nueva transacción'),
            'remainder' => __('Próxima expiración de su pago'),
            'expiration' => __('El pago de su servicio ha expirado'),
        ];

        $user = User::find($this->user_uid);
        $token = $user->generateHumanToken();

        $mail = app(Mailer::class);
        $mail->addAddress($to ?? $user->email, $user->display_name);
        $mail->Subject = __($subjects[$type]);

        $params = [
            'user' => $user,
            'transaction' => $this,
            'wallet' => Wallet::find($this->user_uid),
            'add_funds_link' => sprintf(
                '%s?auth_token=%s:%s',
                config('wallet.add_funds_url'),
                $token->key,
                $token->secret
            ),
        ];

        $mail->Body = view(
            "wallet::emails/notify_${type}",
            $params
        )->render();
        $mail->AltBody = strip_tags($mail->Body);
        return $mail->send();
    }

    public static function emailDailyRemainders(): int {
        $remainders = preg_split('/, ?/', config('wallet.remind_due_at'));

        $query = static::select();
        foreach ($remainders as $remainder) {
            $query->orWhere([
                ['due_at', '>', Carbon::now()->add($remainder)->sub('1 day')],
                ['due_at', '<=', Carbon::now()->add($remainder)],
            ]);
        }
        $query->orderBy('due_at', 'asc');

        $notified_uids = [];
        foreach ($query->get() as $transaction) {
            if (!in_array($transaction->user_uid, $notified_uids)) { // Notify 1st due only
                (new static($transaction))->emailNotification('remainder');
                $notified_uids[] = $transaction->user_uid;
            }
        }

        return count($notified_uids);
    }

    public static function emailDailyExpirations(): int {
        $query = static::select()->where([
            ['due_at', '>', Carbon::now()->sub('1 day')],
            ['due_at', '<=', Carbon::now()],
        ]);

        foreach ($query->get() as $transaction) {
            (new static($transaction))->emailNotification('expiration');
        }

        return $query->count();
    }

    protected static function bcAbs(string $value)
    {
        return trim($value, '-');
    }
}
